Skip to content

feat: support experimental.serverActions.bodySizeLimit from next.config#338

Merged
james-elicx merged 3 commits intocloudflare:mainfrom
youanden:feat/body-size-limit
Mar 8, 2026
Merged

feat: support experimental.serverActions.bodySizeLimit from next.config#338
james-elicx merged 3 commits intocloudflare:mainfrom
youanden:feat/body-size-limit

Conversation

@youanden
Copy link
Contributor

@youanden youanden commented Mar 8, 2026

Adds support for reading experimental.serverActions.bodySizeLimit from next.config.ts and using it to configure the server action request body size limit. Currently, vinext hardcodes __MAX_ACTION_BODY_SIZE to 1MB (1 * 1024 * 1024), ignoring any user configuration and causing 413 Payload Too Large errors when uploading files >1MB via server actions.

// next.config.ts
export default {
  experimental: {
    serverActions: {
      bodySizeLimit: "10mb", // ❌ ignored by vinext — always 1MB
    },
  },
};

Next.js respects this setting (docs), but vinext's generated dev server entry hardcodes the limit.

Problem

I faced an issue uploading files above 1MB in dev mode that drove this PR's creation. I had created a patch in my repo beforehand to correct the problem locally:

// patches/vinext@0.0.24.patch
diff --git a/dist/config/next-config.js b/dist/config/next-config.js
index 3f049032fc986ad4561cb2674cac11bc244cc7b6..a781da763fa7ac2c10655df2a8a0b127bbc882b4 100644
--- a/dist/config/next-config.js
+++ b/dist/config/next-config.js
@@ -10,6 +10,27 @@ import { createRequire } from "node:module";
 import fs from "node:fs";
 import { PHASE_DEVELOPMENT_SERVER } from "../shims/constants.js";
 import { normalizePageExtensions } from "../routing/file-matcher.js";
+/**
+ * Parse a body size limit value (string or number) into bytes.
+ * Accepts Next.js-style strings like "1mb", "500kb", "10mb".
+ * Returns the default 1MB if the value is not provided or invalid.
+ */
+function parseBodySizeLimit(value) {
+    if (value === undefined || value === null) return 1 * 1024 * 1024;
+    if (typeof value === "number") return value;
+    if (typeof value !== "string") return 1 * 1024 * 1024;
+    const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)$/i);
+    if (!match) return 1 * 1024 * 1024;
+    const num = parseFloat(match[1]);
+    const unit = match[2].toLowerCase();
+    switch (unit) {
+        case "b": return Math.floor(num);
+        case "kb": return Math.floor(num * 1024);
+        case "mb": return Math.floor(num * 1024 * 1024);
+        case "gb": return Math.floor(num * 1024 * 1024 * 1024);
+        default: return 1 * 1024 * 1024;
+    }
+}
 const CONFIG_FILES = [
     "next.config.ts",
     "next.config.mjs",
@@ -105,6 +126,7 @@ export async function resolveNextConfig(config) {
             i18n: null,
             mdx: null,
             serverActionsAllowedOrigins: [],
+            serverActionsBodySizeLimit: 1 * 1024 * 1024,
         };
     }
     // Resolve redirects
@@ -135,12 +157,13 @@ export async function resolveNextConfig(config) {
     }
     // Extract MDX remark/rehype plugins from @next/mdx's webpack wrapper
     const mdx = extractMdxOptions(config);
-    // Resolve serverActions.allowedOrigins from experimental config
+    // Resolve serverActions.allowedOrigins and bodySizeLimit from experimental config
     const experimental = config.experimental;
     const serverActionsConfig = experimental?.serverActions;
     const serverActionsAllowedOrigins = Array.isArray(serverActionsConfig?.allowedOrigins)
         ? serverActionsConfig.allowedOrigins
         : [];
+    const serverActionsBodySizeLimit = parseBodySizeLimit(serverActionsConfig?.bodySizeLimit);
     // Warn about unsupported options (skip webpack if we extracted MDX from it)
     const unsupported = mdx ? [] : ["webpack"];
     for (const key of unsupported) {
@@ -177,6 +200,7 @@ export async function resolveNextConfig(config) {
         i18n,
         mdx,
         serverActionsAllowedOrigins,
+        serverActionsBodySizeLimit,
     };
 }
 /**
diff --git a/dist/index.js b/dist/index.js
index 6659b4a0edab80f91a21547ae08c6340ee101c7a..a30439bc9a17fbb3905eeb0f95ddabd12d4981c2 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -2139,6 +2139,7 @@ hydrate();
                         headers: nextConfig?.headers,
                         allowedOrigins: nextConfig?.serverActionsAllowedOrigins,
                         allowedDevOrigins: nextConfig?.serverActionsAllowedOrigins,
+                        bodySizeLimit: nextConfig?.serverActionsBodySizeLimit,
                     });
                 }
                 if (id === RESOLVED_APP_SSR_ENTRY && hasAppDir) {
diff --git a/dist/server/app-dev-server.js b/dist/server/app-dev-server.js
index e76418758ec636d05f0ea14ee7fffc367bbf937c..8ec09db8e5a8dc4e74cbe69904fb534923a06fe8 100644
--- a/dist/server/app-dev-server.js
+++ b/dist/server/app-dev-server.js
@@ -25,6 +25,7 @@ export function generateRscEntry(appDir, routes, middlewarePath, metadataRoutes,
     const rewrites = config?.rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] };
     const headers = config?.headers ?? [];
     const allowedOrigins = config?.allowedOrigins ?? [];
+    const bodySizeLimit = config?.bodySizeLimit ?? 1 * 1024 * 1024;
     // Build import map for all page and layout files
     const imports = [];
     const importMap = new Map();
@@ -1112,12 +1113,13 @@ function __isExternalUrl(url) {
 }
 
 /**
- * Maximum server-action request body size (1 MB).
- * Matches the Next.js default for serverActions.bodySizeLimit.
+ * Maximum server-action request body size.
+ * Configurable via experimental.serverActions.bodySizeLimit in next.config.
+ * Defaults to 1MB, matching the Next.js default.
  * @see https://nextjs.org/docs/app/api-reference/config/next-config-js/serverActions#bodysizelimit
  * Prevents unbounded request body buffering.
  */
-var __MAX_ACTION_BODY_SIZE = 1 * 1024 * 1024;
+var __MAX_ACTION_BODY_SIZE = ${JSON.stringify(bodySizeLimit)};
 
 /**
  * Read a request body as text with a size limit.

Test plan

Added 14 unit tests covering string parsing (mb/kb/gb/b), numeric passthrough, case insensitivity, fractional values, and default/invalid fallbacks
Added integration tests verifying resolveNextConfig() end-to-end with experimental.serverActions.bodySizeLimit

All existing tests continue to pass, oxlint was clean, typecheck had 4 existing errors prior to my changes.

@james-elicx
Copy link
Collaborator

/bigbonk review

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 8, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@338

commit: ebf8a1e

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good feature addition — the 1MB hardcoded limit was a real pain point for file uploads. The overall architecture is correct: config parsing → resolved config → plumbed through generateRscEntry → embedded in generated code. The production server and Cloudflare worker entry inherit the limit from the built RSC entry, so they're covered automatically.

Tests pass and cover the main cases well. A few issues to address below, ranging from a behavioral mismatch with Next.js to minor suggestions.

* Accepts Next.js-style strings like "1mb", "500kb", "10mb".
* Returns the default 1MB if the value is not provided or invalid.
*/
export function parseBodySizeLimit(value: string | number | undefined | null): number {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Next.js validates that the parsed value is >= 1 and throws an error for negative numbers or zero (see packages/next/src/server/config.ts). This implementation silently accepts 0, negative numbers, and "0mb" as valid. Consider adding validation:

if (value <= 0) return 1 * 1024 * 1024;

or even throwing, to match Next.js behavior:

if (value === null || isNaN(value) || value < 1) {
  throw new Error(
    'Server Actions Size Limit must be a valid number or filesize format larger than 1MB: https://nextjs.org/docs/app/api-reference/next-config-js/serverActions#bodysizelimit'
  );
}

Not a blocker, but something to consider for parity.

* Prevents unbounded request body buffering.
*/
var __MAX_ACTION_BODY_SIZE = 1 * 1024 * 1024;
var __MAX_ACTION_BODY_SIZE = ${JSON.stringify(bodySizeLimit)};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON.stringify(bodySizeLimit) works correctly here since bodySizeLimit is always a number — it'll produce e.g. 10485760 in the generated code. Just noting for future readers that JSON.stringify on a number is equivalent to String() / template interpolation, so this is fine.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 8, 2026

Review submitted. Here's a summary of my feedback on PR #338:

Overall: The PR correctly implements the feature. The config parsing pipeline (next.config.tsresolveNextConfigindex.tsgenerateRscEntry → embedded __MAX_ACTION_BODY_SIZE) is properly wired end-to-end. Production builds and Cloudflare worker entries inherit the limit from the built RSC entry, so no changes needed there. The Pages Router API route hardcoded limits (api-handler.ts, index.ts:handleApiRoute) are a separate concern (bodyParser.sizeLimit) and are correctly left unchanged.

Key feedback:

  1. Behavioral mismatch with Next.js (most important): The parser regex requires a unit suffix (b|kb|mb|gb), but Next.js's bytes library accepts bare number strings like "1048576" (treated as bytes), and also supports tb/pb units. Suggested making the unit optional and adding tb/pb to the switch.

  2. Missing validation: Next.js validates that the parsed value is >= 1 and throws for negative/zero values. This implementation silently accepts them.

  3. Minor notes: Unreachable type guard on line 22, a test assertion for "10tb" that would change if tb support is added, and confirmation that JSON.stringify on the number output is fine.

github run

youanden and others added 2 commits March 8, 2026 13:37
- Accept bare number strings (e.g. "1048576") as bytes, matching Next.js bytes library behavior
- Add tb and pb unit support
- Throw on zero/negative values to match Next.js validation behavior
- Remove unreachable type guard (value is always string at that point)
- Update tests: add tb/pb/bare-number/validation cases, fix "10tb" assertion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@youanden youanden force-pushed the feat/body-size-limit branch from 7a9b6d7 to 226f033 Compare March 8, 2026 17:37
@james-elicx
Copy link
Collaborator

/bigbonk review this pr please

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good work. This is a well-scoped fix for a real problem — the hardcoded 1MB limit was silently breaking file uploads. The plumbing is correct: next.configresolveNextConfigindex.tsgenerateRscEntry → embedded __MAX_ACTION_BODY_SIZE. Production builds inherit the value from the built RSC entry, so no changes needed there. Tests are thorough and all pass.

I have two minor notes below, neither blocking.

}
const trimmed = value.trim();
const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb|pb)?$/i);
if (!match) return 1 * 1024 * 1024;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent error handling between invalid strings and invalid numbers: parseBodySizeLimit(0) throws, but parseBodySizeLimit("abc") silently returns the 1MB default. A user who typos their config as bodySizeLimit: "10mbb" would get no feedback that their setting was ignored.

Consider logging a warning when the regex doesn't match a non-empty string, so misconfiguration doesn't go unnoticed:

Suggested change
if (!match) return 1 * 1024 * 1024;
if (!match) {
console.warn(`[vinext] Could not parse serverActions.bodySizeLimit value "${value}", using default 1MB`);
return 1 * 1024 * 1024;
}

Not blocking — the silent default matches how vinext handles most config fallbacks, so this is a style call.

case "pb": bytes = Math.floor(num * 1024 * 1024 * 1024 * 1024 * 1024); break;
default: return 1 * 1024 * 1024;
}
if (bytes < 1) throw new Error(`Body size limit must be a positive number, got ${bytes}`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bytes < 1 check after the switch can only trigger for string inputs like "0b" or "0.5b" (which floors to 0). For numeric inputs, the value < 1 check on line 24 already covers this.

The string "0mb" also produces bytes = 0 and throws here, which is good. But "0.001kb" produces Math.floor(0.001 * 1024) = 1 — which passes. This is fine in practice, just noting the edge for awareness.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 8, 2026

Review submitted with approval. Here's a summary:

Verdict: Approved

The PR correctly wires experimental.serverActions.bodySizeLimit from next.config through the config resolution pipeline into the generated RSC entry's __MAX_ACTION_BODY_SIZE. The architecture is sound — production builds and Cloudflare worker entries inherit the limit from the built RSC entry, so no changes are needed there. All 27 tests pass, including good coverage of unit parsing, edge cases, and integration with resolveNextConfig.

Two non-blocking notes I left:

  1. Silent fallback on invalid stringsparseBodySizeLimit("10mbb") silently returns 1MB with no warning, while parseBodySizeLimit(0) throws. A console.warn on regex mismatch would help users catch typos.
  2. Edge case awareness on the bytes < 1 check — it correctly catches "0mb" and "0b", just noting the boundary behavior for values like "0.001kb" (floors to 1, which passes).

github run

… back

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@james-elicx
Copy link
Collaborator

/bigbonk re-review please!

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, well-scoped feature. The plumbing from next.configresolveNextConfigindex.tsgenerateRscEntry → embedded __MAX_ACTION_BODY_SIZE is correct. Production builds inherit the value from the built RSC entry, so no changes needed there. The Pages Router bodyParser.sizeLimit in index.ts:1570 and api-handler.ts:40 are separate concerns and correctly left untouched.

The author has addressed the feedback from previous reviews: bare number strings work, tb/pb are supported, invalid strings warn instead of silently falling back, and zero/negative values throw. Tests are thorough (27 passing). Two small non-blocking notes below.

case "pb": bytes = Math.floor(num * 1024 * 1024 * 1024 * 1024 * 1024); break;
default: return 1 * 1024 * 1024;
}
if (bytes < 1) throw new Error(`Body size limit must be a positive number, got ${bytes}`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the bytes < 1 check catches "0b", "0mb", and "0.5b" (floors to 0), which is good — but none of these string-zero cases are tested. The test only covers numeric 0 and -1. Consider adding a quick assertion for string edge cases:

expect(() => parseBodySizeLimit("0b")).toThrow();
expect(() => parseBodySizeLimit("0mb")).toThrow();

Not blocking.

export function parseBodySizeLimit(value: string | number | undefined | null): number {
if (value === undefined || value === null) return 1 * 1024 * 1024;
if (typeof value === "number") {
if (value < 1) throw new Error(`Body size limit must be a positive number, got ${value}`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor inconsistency: numeric 0.5 passes (it's >= 1 as false wait — 0.5 < 1 is true, so this throws, which is correct). But a numeric 1.5 is accepted as-is (1.5 bytes), while a string "1.5b" gets Math.floor'd to 1. This is consistent with how Next.js's bytes library works (numeric passthrough, string parsing floors), so it's fine — just noting it for awareness.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 8, 2026

Review submitted with approval. Here's a summary:

Verdict: Approved

The PR correctly wires experimental.serverActions.bodySizeLimit from next.config through the full pipeline: parseBodySizeLimit()resolveNextConfig()index.ts (passes to generateRscEntry) → embedded __MAX_ACTION_BODY_SIZE in the generated RSC entry. Production builds inherit the value automatically.

The implementation addresses all previous review feedback — bare number strings, tb/pb units, console.warn on invalid strings, and throwing on zero/negative values. The 27 tests all pass and cover the important cases well.

Two non-blocking notes I left:

  1. Missing test coverage for string-zero edge cases"0b" and "0mb" correctly throw at runtime, but aren't tested. Quick addition.
  2. Numeric vs string fractional behavior awareness — numeric 1.5 passes through as-is while string "1.5b" gets Math.floor'd. This matches the bytes library behavior, just noted for awareness.

github run

@james-elicx james-elicx enabled auto-merge (squash) March 8, 2026 19:01
@james-elicx james-elicx merged commit 8a4929d into cloudflare:main Mar 8, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants